Repository navigation
Conversation
Store trace_id and workflow_name on incidents, return them from the incident endpoints and mctl_get_incident, and derive a trace id at read time for argo-workflows incidents from the workflow's submit audit record. Proposal: platform-gitops/agents-state/mctl-api/proposals/issue-585-feat-incidents-store-and-return-trace-id/ Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Claude finished @mctl-agents[bot]'s task in 5m 53s —— View job Review complete — changes requested
0 P1 · 3 P2 · 4 P3 — review, all inline comments bound to P2 — should fix before merge
P3 — nice to have
Verified cleanColumn and placeholder ordering across the new |
| } | ||
| deriveIncidentTrace(r.Context(), a, lookup, func(e *audit.Entry) bool { | ||
| team := auditEntryTenant(e) | ||
| return user.IsAdmin() || team == "" || user.HasTenantAccess(team) |
There was a problem hiding this comment.
P2 — Permissive team == "" turns this into an existence/trace oracle for platform-scoped workflows.
The workflow name fed into the audit lookup is caller-controlled: a tenant developer can POST an incident with source: "argo-workflows" and a summary containing any https://workflows.mctl.ai/workflows/<ns>/<name> URL (or send workflow_name directly), then GET it. The distinct trace_unavailable values (audit_record_not_found vs no_traceparent_in_audit_record) plus a returned trace_id reveal whether an audit record exists for an arbitrary workflow name, and hand over its trace id.
auditEntryTenant(e) == "" means an AdminOnly platform-scoped operation — see the comment at internal/api/handlers_read.go:520-527, where GetWorkflowLogs deliberately denies non-admins in exactly this case. Here the empty-team branch grants access instead. Suggest mirroring the stricter gate:
return user.IsAdmin() || (team != "" && user.HasTenantAccess(team))Tightening further to require team == a.Tenant for non-admins would also remove the cross-tenant probing surface, since the audit entry's team need not relate to the incident the caller was authorized for.
There was a problem hiding this comment.
Fixed in 869866c. canReadAuditEntry lets a non-admin derive only from an audit entry whose team they belong to, so an empty-team (AdminOnly platform) entry is admin-only. I know you withdrew this P2 in 4252021227, since GetWorkflow already serves the traceparent. I kept the stricter gate anyway: the incident path has no reason to widen access, and argo incidents are posted to admins. The comment on the function now says so. Covered by TestCanReadAuditEntry and, through GetIncident with real non-admin users, by TestIncidentTrace_GetAsNonAdmin (b87e160). Mutation M12 fails that test.
| created_at, updated_at, resolved_at, acknowledged_by, acknowledged_at, | ||
| fingerprint, occurrence_count, last_seen_at`, | ||
| updated_at = EXCLUDED.updated_at, | ||
| trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END |
There was a problem hiding this comment.
P2 — Dedup updates trace_id but not workflow_name, so the two can end up describing different runs.
trace_id and workflow_name are two views of the same thing: the run that handled the incident. On the dedup path the new occurrence's trace_id overwrites the stored one, while workflow_name keeps occurrence #1's value (it is absent from the DO UPDATE SET list, and Update at line 209 does not touch it either).
Concretely, with a fingerprint that is stable across runs of the same failing template (the normal case for ArgoWorkflowFailed):
- occurrence ci: Bump docker/setup-buildx-action from 3 to 4 #1 posts
workflow_name: wf-aand no trace -> row holds(wf-a, "") - occurrence ci: Bump docker/build-push-action from 5 to 7 #2 posts
workflow_name: wf-b,trace_id: T_b-> row holds(wf-a, T_b)
A later consumer reading workflow_name gets wf-a and a trace from wf-b. Symmetrically, if #1 carried a trace and #2 did not, deriveIncidentTrace would never run (reported wins) even though the stored trace is for a stale run.
Either move them together:
trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END,
workflow_name = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name ELSE alerts.workflow_name ENDor keep first-write-wins for both. Whichever is chosen, a store test asserting the dedup outcome for the pair would pin it down — the dedup path has no test for the new columns at all.
There was a problem hiding this comment.
Fixed in 869866c. The pair now moves together on dedup. A new valid trace replaces both trace_id and workflow_name. A hit without a trace keeps both. While no trace is stored, a newer non-empty workflow_name replaces the older one, so derivation uses the latest run. This deviates from the proposal's "workflow_name immutable after create", which is recorded in the PR body. TestTraceID_Dedup pins all of these cases. Mutation M9 fails it.
| traceSourceAuditDerive = "workflow_audit" | ||
| ) | ||
|
|
||
| var workflowURLRe = regexp.MustCompile(`(?:^|\s)https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9][a-z0-9.-]*)`) |
There was a problem hiding this comment.
P2 — The capture group swallows trailing punctuation, so a URL ending a sentence silently loses its trace.
([a-z0-9][a-z0-9.-]*) includes . and -, and the match is greedy to end of the run. For the very common summary shape where the URL terminates a sentence:
"Workflow failed: https://workflows.mctl.ai/workflows/argo-workflows/mctl-agents-implement-dae24112."
the capture is mctl-agents-implement-dae24112. — and IsDNS1123Subdomain rejects a trailing dot (the name must end alphanumeric), so line 100's guard fails and deriveIncidentTrace returns workflow_name_unknown for an incident whose workflow name is right there in the summary. A trailing - behaves the same way. The sole existing test ("summary url") uses an unpunctuated URL, so this is not covered.
Easiest fix is to anchor the end of the name instead of relying on the character class:
var workflowURLRe = regexp.MustCompile(`(?:^|\s)https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)`)Worth adding table cases for a trailing ., a trailing ), and a URL in parentheses — note that (?:^|\s) also means "...failed (https://workflows.mctl.ai/...)" does not match at all, since ( is neither start-of-string nor whitespace.
There was a problem hiding this comment.
Fixed in 869866c. The pattern is now (?:^|[\s(])…/([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?). TestDeriveIncidentTrace_SummaryURLInProse covers a trailing ., a parenthesised URL, ). and a trailing -. Mutation M8, which restores the original pattern, fails it.
| `UPDATE alerts SET status=$2, analysis=$3, proposed_fix=$4, pr_url=$5, pr_number=$6, | ||
| confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11 | ||
| confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11, | ||
| trace_id=$12 |
There was a problem hiding this comment.
P3 — Update now clobbers trace_id from the in-memory value, which is a footgun for future callers.
All three current callers (UpdateIncident, AcknowledgeIncident, ResolveIncident) load existing via GetByPrefix first, so a.TraceID is always populated and nothing is lost today. But any future caller that builds a partial *Alert to update a couple of fields will silently blank a stored trace id, and the failure is invisible (no error, the column just becomes the empty string).
Consider making it non-destructive, matching the Create dedup policy — i.e. assign CASE WHEN <param> <> %s THEN <param> ELSE alerts.trace_id END instead of the bare parameter.
If clearing a wrong trace id is a wanted capability it needs an explicit path rather than falling out of a zero value: validIncidentTraceID maps both "absent" and "invalid" to the empty string, so a PATCH can never reset one anyway.
There was a problem hiding this comment.
Fixed in 869866c: trace_id=CASE WHEN $12 <> '' THEN $12 ELSE trace_id END. A partial update that carries no trace now keeps the stored one. Asserted in TestTraceID_RoundTripsThroughEveryReadPath. Mutation M10 fails it.
| writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) | ||
| return | ||
| } | ||
| a := body.Alert |
There was a problem hiding this comment.
P3 — The "exactly one is set" invariant does not hold on the create or patch response.
incident_trace.go:31 states that exactly one of trace_id / trace_unavailable is set, and the new MCP description promises callers that a missing trace_id always carries a reason. CreateIncident (201, line 97) and UpdateIncident (200, line 323) both return the stored alert without running deriveIncidentTrace and without setting a reason, so an argo-workflows incident created with no trace comes back with both fields empty — exactly the "this incident has no trace" misreading the ListIncidents loop at line 203 was added to prevent.
Setting TraceUnavailable = traceNotDerivedOnList on these two responses as well would make the contract uniform at no cost.
Separately, the shadowed-json.RawMessage decode trick here is subtle and has no HTTP-level test: TestValidIncidentTraceInputs exercises the validators directly, but nothing asserts that POST /api/v1/incidents with "trace_id": 123 still succeeds (rather than 400) and stores no trace, nor that "trace_source" / "trace_unavailable" sent by a client cannot reach the stored row. A small httptest case over the router would lock in the whole point of the shadowing.
There was a problem hiding this comment.
Fixed in 869866c. Create, patch, ack and resolve responses go through labelStoredTrace. An argo incident without a trace says not_derived, and any other source says not_reported. TestIncidentTrace_WriteResponsesAreLabelled asserts the 201 and 200 bodies (b87e160). Mutation M13 fails it. The HTTP-level shadowing check, a numeric trace_id returning 201 with nothing stored, is in TestIncidentTrace_InvalidInputIgnoredNotRejected.
| // never read as "this incident has no trace". | ||
| for i := range items { | ||
| if items[i].TraceID == "" { | ||
| items[i].TraceUnavailable = traceNotDerivedOnList |
There was a problem hiding this comment.
P3 — not_derived_on_list is also applied to incidents where derivation could never produce a trace.
For a non-argo-workflows incident deriveIncidentTrace returns not_reported — a terminal answer. Labelling those not_derived_on_list tells the caller (and the MCP client, per the new tool description: "call this tool to resolve it") to spend a GET /incidents/{id} that cannot possibly change the outcome. Gating on the source keeps the two reasons honest:
for i := range items {
if items[i].TraceID != "" {
continue
}
if items[i].Source == incidentSourceArgo {
items[i].TraceUnavailable = traceNotDerivedOnList
} else {
items[i].TraceUnavailable = traceNotReported
}
}There was a problem hiding this comment.
Fixed in 869866c. On the list, not_derived_on_list is only applied to argo incidents, and non-argo incidents without a trace get not_reported. The list test now seeds a non-argo untraced item. Mutation M11 fails it.
| } | ||
| a.TraceSource = "" | ||
| if a.TraceID != "" { | ||
| a.TraceSource = "reported" |
There was a problem hiding this comment.
P3 — The store sets a field its own doc comment calls response-only.
types.go:82-84 says TraceSource is "response-only and never persisted", yet scanAlert derives it on every read path, including List and Summary. It is harmless (nothing writes it back, and deriveIncidentTrace resets it on the one path that matters) but it puts presentation logic in the persistence layer and gives List results a trace_source that no derivation produced.
Dropping the two lines and letting the handlers own both response-only fields would keep the layering consistent with the comment.
There was a problem hiding this comment.
Fixed in 869866c. scanAlert no longer sets TraceSource; the handlers set both response-only fields.
| ) | ||
|
|
||
| // trace_unavailable reasons. Exactly one is set when no trace id is returned. | ||
| const ( |
There was a problem hiding this comment.
P3 — Two nits on this const block.
- The Argo UI hostname is hard-coded in
workflowURLRe(line 45) and appears nowhere else in non-test code, so a different or additional Argo host (staging, a self-hosted install) silently breaks summary-based derivation with no way to configure it. Since producers are expected to sendworkflow_namedirectly, the regex is a legacy-row fallback — worth saying so in the comment, or sourcing the host from the same config the notifier uses. incidentSourceArgoandincidentAuditTimeoutsit in a block whose comment reads "trace_unavailable reasons. Exactly one is set when no trace id is returned." Splitting the timeout and the source constant into their own block would stop the comment from being wrong about three of its members.
There was a problem hiding this comment.
Fixed in 869866c. The constants are split into reasons, sources and the source/timeout pair. The workflowURLRe comment says it is the fallback for rows posted without workflow_name, which is every argo incident today.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 3 P2, 4 P3.
P2: (1) GetIncident grants trace derivation when the audit entry has no owning team, and the looked-up workflow name is caller-controlled via summary/workflow_name — an existence-and-trace oracle for AdminOnly platform workflows, contrary to the stricter gate GetWorkflowLogs uses for the same empty-team case. (2) The Create dedup path updates trace_id but not workflow_name, so a row can end up pairing one run name with another run trace. (3) The summary URL regex captures a trailing ./-, so a URL ending a sentence fails DNS1123 validation and yields workflow_name_unknown.
Column/placeholder ordering in the new alertColumns + scanAlert pair was checked and is consistent across INSERT, Update, Get, GetByPrefix and List; the audit.Log interface addition is satisfied by both implementations. go build and go test were not available in this review sandbox, so compile and test status is unverified.
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit Findings1. Inconsistent conflict resolution drops or desynchronizes
|
The implementer PR shipped only the pure derivation and helper tests; the proposal's store round-trip (T1), dedup (T2), every-read-path regression (T3), lenient-input (T4) and list-marking (T8) tests were missing, and nothing proved any of them could fail. Add them, plus a Postgres test that LookupByWorkflow returns an error rather than "not found" on a failed read, and an end-to-end GET on the production dae24112 incident text. Each was shown red with its bug put back. Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
|
Claude finished @mashkovd's task in 6m 28s —— View job Review complete — changes requested
0 P1 · 2 P2 · 4 P3 — review, all inline comments bound to
P2 — should fix before merge
Correction to my previous reviewThe P3 — nice to have
Verified cleanColumn and placeholder ordering across |
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
| traceSourceAuditDerive = "workflow_audit" | ||
| ) | ||
|
|
||
| var workflowURLRe = regexp.MustCompile(`(?:^|\s)https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9][a-z0-9.-]*)`) |
There was a problem hiding this comment.
P2 — Still open from the previous round, and the new tests do not close it.
([a-z0-9][a-z0-9.-]*) includes . and matches greedily to the end of the run, so a URL that terminates a sentence captures the trailing punctuation:
"Workflow failed: https://workflows.mctl.ai/workflows/argo-workflows/mctl-agents-implement-dae24112."
captures mctl-agents-implement-dae24112., which IsDNS1123Subdomain rejects (a subdomain must end alphanumeric), so line 100 falls through to workflow_name_unknown for an incident whose workflow name is right there in the summary. A trailing - behaves the same way.
The new coverage uses daeSummary (incident_trace_handlers_test.go:37), copied verbatim from the production notifier — which happens to emit the URL last with no punctuation. So both the unit case and the new handler case exercise only the shape that already worked; the punctuated shape is still untested.
One-line fix, anchoring the end of the name rather than relying on the character class:
var workflowURLRe = regexp.MustCompile(`(?:^|[\s(])https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)`)Note the leading class as well: (?:^|\s) means "... failed (https://workflows.mctl.ai/...)" does not match at all, since ( is neither start-of-string nor whitespace. Table cases for a trailing ., a trailing ), and a parenthesised URL would pin both down.
| if got.ID != first.ID || got.OccurrenceCount != 2 { | ||
| t.Fatalf("second create did not dedup: id=%q count=%d", got.ID, got.OccurrenceCount) | ||
| } | ||
| assertTrace(t, "dedup with new trace", got, traceB, "wf-first") |
There was a problem hiding this comment.
P2 — This test now codifies the workflow_name/trace_id desync rather than resolving it.
The assertion is (workflow_name = "wf-first", trace_id = traceB): run A's name paired with run B's trace. That is exactly the inconsistency raised last round, now locked in by a test, with no comment anywhere explaining why the two fields are allowed to diverge.
Why it matters beyond tidiness: both fields are serialized on the incident response (types.go:79-80) and surfaced through mctl_get_incident. The feature exists so a responder can pivot incident -> workflow -> trace -> logs. A responder who reads workflow_name: wf-first and fetches that run's logs gets the wrong run, because the trace alongside it belongs to wf-second. During an incident that is a wrong answer, not a cosmetic one.
Two self-consistent options:
-- latest-wins for the pair
trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END,
workflow_name = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name ELSE alerts.workflow_name ENDor first-write-wins for both (drop trace_id from DO UPDATE SET).
If the current split really is deliberate — e.g. "workflow_name names the run that opened the incident, trace_id the most recent one" — that is defensible, but it needs saying in types.go next to the field docs, because nothing in the API tells a client the two refer to different runs. The test comment "workflow_name stays the original" describes the behaviour without justifying it.
Related: line 91 says "workflow_name is immutable", which reads as an enforced invariant. It is actually just that Store.Update does not list the column, so after.WorkflowName = "something-else" is silently dropped rather than rejected. Worth phrasing as "Update does not persist workflow_name" so a future reader does not rely on an immutability guarantee that is not implemented.
| } | ||
| deriveIncidentTrace(r.Context(), a, lookup, func(e *audit.Entry) bool { | ||
| team := auditEntryTenant(e) | ||
| return user.IsAdmin() || team == "" || user.HasTenantAccess(team) |
There was a problem hiding this comment.
P3 — Correcting my own P2 from the previous round: this is not a privilege escalation. It is a consistency question plus a coverage gap.
I claimed the permissive team == "" branch made this a trace oracle for platform-scoped workflows. Checking the rest of the package, that does not hold:
GetWorkflow(handlers_read.go:447) uses!user.IsAdmin() && team != "" && !user.HasTenantAccess(team)— the same tolerance of an empty team, for a caller-supplied workflow name, on the same non-admin route group (router.go:428).audit.RedactEntrymasks on value length (>= 100) and credential markers (redact.go:22-36). A 55-character lowercase-hex traceparent matches neither, soGET /api/v1/workflows/{name}already returnsaudit.parameters.traceparentverbatim to any authenticated non-admin, for any workflow name including empty-team ones.
So the trace id reachable here is already reachable by the same caller through an existing endpoint, and the existence signal is too. No new capability is granted, and my suggested tightening would have made this stricter than the nearest precedent for no gain.
What is genuinely worth addressing:
- Two gates coexist for the empty-team case —
GetWorkflowpermits,GetWorkflowLogs(handlers_read.go:520-531) denies, each with a comment explaining itself. This closure matches the former but says nothing. A one-line comment ("a trace id exposes no more than GetWorkflow already does; step logs are gated separately") would stop the next reader from relitigating it, as I just did. - The closure is the only line in the diff that makes an authorization decision, and no test exercises it with a real
*auth.User.incident_trace_test.gocoversderiveIncidentTracewithallow/denystubs, andTestIncidentTrace_GetDerivesDae24112FromAuditonly usesadminUser(). A non-admin case over an entry with ateam_namethey do not have (expectaudit_record_forbidden) and one with no team (expect the derived trace) would cover both branches cheaply, using thewithUserhelper already in the package.
There was a problem hiding this comment.
Thanks for the correction. I kept the stricter gate and documented why. Both branches are now covered with real *auth.User values through GetIncident in TestIncidentTrace_GetAsNonAdmin: own team gives the trace, another team gives audit_record_forbidden, and a platform op gives audit_record_forbidden.
| // never read as "this incident has no trace". | ||
| for i := range items { | ||
| if items[i].TraceID == "" { | ||
| items[i].TraceUnavailable = traceNotDerivedOnList |
There was a problem hiding this comment.
P3 — Still open: not_derived_on_list is applied to incidents where derivation could never produce a trace, and the new test does not cover that case.
For a non-argo-workflows incident, deriveIncidentTrace returns not_reported — terminal. Labelling those not_derived_on_list tells the caller (and the MCP client, per the new tool description: "call this tool to resolve it") to spend a GET /incidents/{id} that cannot change the outcome.
TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup seeds a source: "manual" incident, but gives it a TraceID, so it takes the continue path. The combination that exposes this — non-argo source and no stored trace — is the one shape the test does not seed.
for i := range items {
if items[i].TraceID != "" {
continue
}
if items[i].Source == incidentSourceArgo {
items[i].TraceUnavailable = traceNotDerivedOnList
} else {
items[i].TraceUnavailable = traceNotReported
}
}There was a problem hiding this comment.
Already fixed in 869866c (see the reply on 4251863225).
| } | ||
| a := body.Alert | ||
| a.TraceID = validIncidentTraceID(body.TraceID) | ||
| a.WorkflowName = validIncidentWorkflowName(body.WorkflowName) |
There was a problem hiding this comment.
P3 — Still open: the "exactly one is set" invariant does not hold on the 201 create or the 200 patch response, and the new handler tests assert only the status code and the stored row, not the response body.
incident_trace.go:31 states that exactly one of trace_id / trace_unavailable is set, and the new MCP description promises that a missing trace_id always carries a reason. CreateIncident (201, line 97) and UpdateIncident (200, line 323) return the stored alert without running deriveIncidentTrace and without setting a reason, so an argo-workflows incident created with no trace comes back with both fields empty — the "this incident has no trace" misreading the ListIncidents loop was added to prevent.
TestIncidentTrace_InvalidInputIgnoredNotRejected checks rec.Code and then re-reads through store.Get, so the response bodies of both endpoints are still unasserted. Setting TraceUnavailable = traceNotDerivedOnList (or traceNotReported per the sibling comment) on these two responses would make the contract uniform, and asserting trace_unavailable on the decoded 201 body would lock it in — decodeIncident is already there for it.
- Derivation gate: an audit entry with no team is an AdminOnly platform operation, and the workflow name is caller-controlled, so non-admins may only derive from entries of their own teams (as GetWorkflowLogs). - Dedup moves trace_id and workflow_name together; while no trace is stored a newer workflow_name replaces the older one. - Summary URL fallback no longer captures sentence punctuation and matches a URL inside parentheses. - Update keeps the stored trace when the caller carries none. - Every response labels the trace: write responses say not_derived for argo incidents, any non-argo incident without a trace says not_reported, and trace_source is set by the handlers, not the store. Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
| } | ||
| a.TraceSource = "" | ||
| if a.TraceID != "" { | ||
| a.TraceSource = "reported" |
There was a problem hiding this comment.
P3 — Still open, and the new store test now promotes this into the persistence layer's contract.
types.go:82-84 says TraceSource is "response-only and never persisted", yet scanAlert derives it on every read path including List and Summary. store_trace_test.go:29-39 (assertTrace) now asserts that trace_source == "reported" comes back from Create, Get, GetByPrefix and List — so the store is no longer just incidentally setting a presentation field, it is tested as owning one, directly against its own doc comment.
It is harmless at runtime (nothing writes it back, and deriveIncidentTrace resets both fields on the one path that matters). But either the store owns trace_source or the handlers do; right now the comment says one thing, the code does another, and the test pins the code. Dropping the two lines and setting the field in the handlers — with assertTrace checking only the two persisted columns — would make all three agree.
Also in this area, carried over: the Argo UI hostname is hard-coded in incident_trace.go:45 and appears nowhere else in non-test code, so a second or renamed Argo host silently breaks summary-based derivation with no way to configure it. Since producers are expected to send workflow_name directly, the regex is really a legacy-row fallback — worth saying so in the comment, or sourcing the host from the same config the notifier uses.
|
Claude finished @mashkovd's task in 5m 47s —— View job Review complete — approve (verdict could not be submitted: head moved)
0 P1 · 0 P2 · 3 P3 for Previous findings: all closed
P3 — nice to have (non-blocking)
Verified clean
|
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 2 P2, 4 P3.
e407e48 is test-only — it adds store_trace_test.go, incident_trace_handlers_test.go and lookup_postgres_test.go and changes no production code, so the previous round's findings all carry over. The new tests are well targeted (the dae24112 end-to-end derivation, the "invalid trace input is ignored, not rejected" matrix, and the proof that ListIncidents never touches the audit log) and they do run in CI, since validate.yml provides a Postgres service and TEST_DATABASE_URL.
P2: (1) The summary URL regex still captures a trailing ./-, so a URL ending a sentence fails DNS1123 validation and yields workflow_name_unknown; the new coverage uses the production summary, which happens to be the shape that already worked. (2) The Create dedup path still updates trace_id without workflow_name, and store_trace_test.go:144 now asserts that pairing (run A's name, run B's trace) as intended, with no rationale recorded — a responder following workflow_name to a run's logs gets the wrong run.
Correction to my previous review: the team == "" branch in GetIncident is not a privilege escalation and is no longer a P2. GetWorkflow (handlers_read.go:447) already tolerates an empty team for a caller-supplied workflow name on the same non-admin route, and audit.RedactEntry does not mask a traceparent, so the same trace id is already reachable through an existing endpoint. It remains a P3 for the undocumented divergence from GetWorkflowLogs and for the authorization closure having no test with a real non-admin user.
Remaining P3s: terminal not_reported incidents are labelled not_derived_on_list on list; the "exactly one of trace_id/trace_unavailable" invariant still does not hold on the 201/200 bodies, which the new tests do not assert; scanAlert still sets the response-only TraceSource, now asserted by assertTrace.
go build and go test were not available in this review sandbox, so compile and test status is unverified — please rely on the validate workflow.
Exercise canReadAuditEntry through GetIncident with real non-admin users and assert the create/patch response bodies, both shown failing with the behaviour reverted. Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
|
Claude finished @mashkovd's task in 7m 4s —— View job Review complete — changes requested
0 P1 · 1 P2 · 3 P3 — review, all inline comments bound to Previous findings: addressed
P2 — should fix before merge
P3 — nice to have
Verified clean
One housekeeping note: the SQL fragment in finding 1's comment body got mangled by shell quoting — the clause it refers to is |
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
| `UPDATE alerts SET status=$2, analysis=$3, proposed_fix=$4, pr_url=$5, pr_number=$6, | ||
| confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11 | ||
| confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11, | ||
| trace_id=CASE WHEN $12 <> '' THEN $12 ELSE trace_id END |
There was a problem hiding this comment.
P3 — the new non-destructive CASE is right, but it leans on Postgres resolving an all-unknown operator to text, and CI cannot catch it if that ever fails.
The fix itself addresses the previous round's finding: all three callers keep their stored trace, and the new partial-update case in TestTraceID_RoundTripsThroughEveryReadPath pins it.
One caveat on the form. In CASE WHEN $12 <> '' both operands arrive untyped, so the statement parses only because Postgres falls back to text when every input to an operator is unknown. It does fall back, so this works today — but the failure mode if it ever did not is every incident PATCH/ack/resolve returning 500, and internal/alerts tests t.Skip without TEST_DATABASE_URL, which is the CI default in this repo. This SQL is new in 869866c (the previous commit had the bare trace_id=$12), so it is worth confirming the Postgres-backed run was repeated on this commit rather than only on e407e48.
Making the type explicit removes the dependence on the fallback:
trace_id=CASE WHEN $12::text <> '' THEN $12::text ELSE trace_id END| updated_at = EXCLUDED.updated_at, | ||
| trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END, | ||
| workflow_name = CASE | ||
| WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name |
There was a problem hiding this comment.
P3 — this branch blanks a known workflow_name, which is the correct reading of the new "they move together" policy but is the one dedup combination the test does not cover.
The pairing fix is good: TestTraceID_Dedup now covers new-trace-replaces-both, no-trace-keeps-both, and newer-name-while-untraced. What is left unexercised is EXCLUDED.trace_id <> '' AND EXCLUDED.workflow_name = '' — an occurrence that reports a trace but no workflow name. The first WHEN then assigns the empty EXCLUDED.workflow_name, so a row that held (wf-a, "") becomes ("", T_b): the incident keeps a trace but loses the workflow link it previously had in the response.
That is self-consistent (the alternative, keeping wf-a next to T_b, is exactly the desync this commit removes), so no SQL change is needed. But a future reader will ask, and the comment above the CASE does not say it. One line in TestTraceID_Dedup:
assertTrace(t, "a trace without a name clears the stale name", occur("argo-dedup-4", fp, traceA, "", 4), traceA, "")plus a half-sentence in the doc comment ("a trace reported without a name clears the stale name rather than pairing it with the new trace") would make the choice deliberate rather than incidental.
| trace_source "reported" (sent by the producer) or "workflow_audit" (derived from the | ||
| failed workflow's submit audit record). When no trace id could be obtained, | ||
| trace_unavailable gives the reason. A missing trace_id means "not known", never | ||
| "no trace exists". List results carry trace_unavailable "not_derived_on_list" |
There was a problem hiding this comment.
P3 — the description enumerates the reason set, and 869866c added two values it does not mention.
The write responses (create, patch, ack, resolve) now return trace_unavailable: "not_derived", and a non-argo incident on a list response gets "not_reported" rather than "not_derived_on_list". Both are improvements over the previous round's "both fields empty", but an MCP client that reads this paragraph as the complete set now sees undocumented values — and the current text specifically tells it a list item carries not_derived_on_list, which is only true for argo incidents.
A sentence covering both keeps the tool description the single source of truth for the contract:
trace_unavailableisnot_reportedwhen no trace can exist for this incident source,not_derived_on_listfor an argo incident on a list response (call this tool to resolve it),not_derivedon a create/patch/ack/resolve response, or one ofworkflow_name_unknown/audit_unavailable/audit_record_not_found/audit_record_forbidden/no_traceparent_in_audit_recordwhen the derivation ran and could not produce one.
| } | ||
| a := body.Alert | ||
| a.TraceID = validIncidentTraceID(body.TraceID) | ||
| a.WorkflowName = validIncidentWorkflowName(body.WorkflowName) |
There was a problem hiding this comment.
P2 — For a deduped recurring argo incident, the derived trace is pinned to the first occurrence, so the responder gets the oldest run.
validIncidentWorkflowName leaves a.WorkflowName empty whenever the producer omits it — which the comment at incident_trace.go:52-53 says is every argo incident today. The Create dedup path (store.go:140-149) deliberately refreshes workflow_name from a newer occurrence while the row is untraced, but that clause can never fire if the producer never sends the field, and summary is not in the DO UPDATE SET list at all. So:
- run 1 fails -> row stored with run 1 summary (
.../mctl-agents-implement-aaa11111), no trace, no name - run 2 fails, same fingerprint, incident still open -> dedup bumps
occurrence_count=2andlast_seen_at, summary and workflow name unchanged GET /incidents/{id}->workflowURLRereads run 1 out of the stale summary ->trace_idof run 1, labelledworkflow_audit
Nothing in the response says the trace is two runs old: occurrence_count and last_seen_at point at run 2, trace_source claims a derived trace, and there is no trace_unavailable. For the use case this PR exists for — pivot incident -> trace -> Loki to see why it just failed — that is a wrong answer, not a missing one. The dedup window is real: the ON CONFLICT predicate only excludes resolved/suppressed, so every retry while the incident is open lands here.
Smallest in-scope fix is to fill the field the dedup clause already handles, right here:
a.WorkflowName = validIncidentWorkflowName(body.WorkflowName)
if a.WorkflowName == "" {
a.WorkflowName = workflowNameFromSummary(a.Summary) // the workflowURLRe match, factored out
}Then occurrence 2 supplies wf-run2, the alerts.trace_id = '"' AND EXCLUDED.workflow_name <> '"' clause promotes it, and deriveIncidentTrace prefers a.WorkflowName over the stale summary. It also makes the stored row self-describing instead of depending on a regex over prose at read time.
Either way this needs a test: TestTraceID_Dedup covers the columns but never the derivation, and TestIncidentTrace_GetDerivesDae24112FromAudit uses a single-occurrence row, so no test exercises GET on a deduped argo incident. If the staleness is accepted instead, it should be stated in the trace_source docs ("workflow_audit names the run recorded in the incident, which for a recurring incident is the first occurrence").
There was a problem hiding this comment.
Fixed in f2472b6. CreateIncident now stores the workflow name parsed from the summary for argo incidents when the producer sends none (workflowNameFromSummary, shared with derivation). The dedup clause carries workflow_name forward on each occurrence while no trace is stored, so derivation follows the newest run. Covered by TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun, which posts run1 and run2 under one fingerprint and expects run2's trace. Mutation check: removing the fallback makes that test fail with derived trace_id=008ad897... workflow_name=<nil>, want the newest run.
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
| updated_at = EXCLUDED.updated_at, | ||
| trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END, | ||
| workflow_name = CASE | ||
| WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name |
There was a problem hiding this comment.
P3 — The first WHEN arm blanks a stored workflow_name, and that case is the one the new dedup test does not cover.
WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name assigns the incoming name unconditionally, including when it is empty. So an occurrence that carries a trace but no workflow name erases a name the row already had:
| stored | incoming | result |
|---|---|---|
('', 'wf-a') |
(traceB, '') |
(traceB, '') — wf-a gone |
That is the shape a producer will have as soon as it starts sending trace_id (the obvious first step) without also sending workflow_name. Arguably it keeps the pair honest — the name of run B genuinely is unknown — but the row then names no workflow at all while summary still links run A, so the "move together" invariant is not actually achieved either; it just drops the one field that was accurate.
TestTraceID_Dedup covers (trace, name), ('', name) and ('', ''), but never (trace, ''), so whichever behaviour is intended is currently unpinned. If keeping wf-a is preferred, the arm is the same CASE shape used for trace_id:
workflow_name = CASE
WHEN EXCLUDED.workflow_name <> '' THEN EXCLUDED.workflow_name
WHEN EXCLUDED.trace_id <> '' THEN ''
ELSE alerts.workflow_name END...or, if blanking is intended, add the row to the test table so a future reader knows it was a decision.
There was a problem hiding this comment.
Intended, and now documented and tested in f2472b6. trace_id and workflow_name describe the same run, so a new trace with no name must not stay paired with the previous run's name. There is a comment on the clause, and a new TestTraceID_Dedup case "a trace without a name clears the stale name". Mutation check: guarding the first arm with AND EXCLUDED.workflow_name <> '' makes that case fail.
| trace_source "reported" (sent by the producer) or "workflow_audit" (derived from the | ||
| failed workflow's submit audit record). When no trace id could be obtained, | ||
| trace_unavailable gives the reason. A missing trace_id means "not known", never | ||
| "no trace exists". List results carry trace_unavailable "not_derived_on_list" |
There was a problem hiding this comment.
P3 — The description is now out of date with the two labelling changes in this commit.
"List results carry trace_unavailable not_derived_on_list" was true of the previous commit, which labelled every traceless item that way. After labelStoredTrace, a non-argo item on the list gets not_reported instead (asserted by TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup), and that one is terminal — calling mctl_get_incident on it cannot change the answer. As written the description sends the model to spend a GET on exactly the items where it is pointless, which is the cost the not_reported split was introduced to avoid.
The new not_derived reason returned by the create/patch/ack/resolve responses is also undocumented anywhere a client can see it.
Suggest naming the two actionable reasons explicitly, e.g.:
trace_unavailable gives the reason a trace id is absent. "not_derived_on_list"
(list results) and "not_derived" (write responses) mean the derivation was not
run — call this tool to resolve them. Every other reason, including
"not_reported", is terminal.
There was a problem hiding this comment.
Fixed in f2472b6. The mctl_get_incident description now lists every trace_unavailable value and says when each one appears (list vs get).
| `UPDATE alerts SET status=$2, analysis=$3, proposed_fix=$4, pr_url=$5, pr_number=$6, | ||
| confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11 | ||
| confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11, | ||
| trace_id=CASE WHEN $12 <> '' THEN $12 ELSE trace_id END |
There was a problem hiding this comment.
P3 — Non-destructive now (good), but it closes the only door for correcting a wrong trace id.
Between this CASE and validIncidentTraceID mapping both "absent" and "invalid" to the empty string, there is no representable request that clears trace_id. If a producer ever posts a syntactically valid but wrong trace (a parent trace, a trace from the wrong leg), the incident is stuck reporting it as trace_source: "reported" forever — and reported beats derivation in deriveIncidentTrace, so the correct derivable trace stays hidden.
Not worth blocking on, and the empty-means-keep rule is the right default for the partial-update callers. Worth a line in the TraceID doc comment (types.go:78-81) saying the value is write-once-per-value and cannot be reset through the API, so the next person does not go looking for the clear path.
Same for workflow_name: it is absent from this SET list and from the UpdateIncident patch struct, so a row created without one can only ever gain one through a dedup hit. The store_trace_test.go:84-85 comment now records that accurately, but the API surface does not.
There was a problem hiding this comment.
Acknowledged and documented in f2472b6 (types.go doc on TraceID/WorkflowName). No API path clears a stored trace, and PATCH never sets workflow_name. A wrong trace can be corrected by PATCHing a new valid trace_id or by a newer occurrence that carries one. Supporting an explicit clear would need a tri-state field in the PATCH body, which is out of scope here. The $12::text cast is applied in the same commit.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 1 P2, 3 P3.
869866c addressed every P2 from the previous rounds: the summary URL regex now anchors the end of the name and accepts a leading open paren (covered by TestDeriveIncidentTrace_SummaryURLInProse, including the trailing period, paren and hyphen shapes); the dedup moves trace_id and workflow_name together; Store.Update is non-destructive; scanAlert no longer sets the response-only TraceSource; canReadAuditEntry is the stricter GetWorkflowLogs gate with the divergence from GetWorkflow documented and tested against real non-admin users; write and list responses are labelled, with not_reported kept terminal for non-argo items. b87e160 adds the tests for the gate and the write-response labels.
Remaining P2: for a recurring argo incident that dedups while still open, the derived trace is the FIRST occurrence's run. The producer sends no workflow_name (per the comment on workflowURLRe), so the dedup clause that refreshes it can never fire, and summary is not in the DO UPDATE SET list — GET parses run 1's URL out of the stale summary and returns run 1's trace as trace_source=workflow_audit while occurrence_count and last_seen_at describe the latest run. Nothing in the response marks it stale. Smallest fix is to fall back to the summary regex in CreateIncident so the stored workflow_name tracks the newest occurrence, which the existing dedup clause already handles. No test exercises GET on a deduped argo incident.
P3: the first WHEN arm of the dedup workflow_name CASE blanks a stored name when the incoming occurrence has a trace but no name, the one combination the new dedup test omits; the mctl_get_incident description still says list results carry not_derived_on_list, now untrue for non-argo items, and does not mention the new not_derived; no representable request can clear a wrong trace_id, and workflow_name cannot be patched at all.
Verified: alertColumns/scanAlert ordering is consistent across INSERT (23 columns / $1..$23), Update, Get, GetByPrefix and List; pgx.Rows satisfies pgx.Row; the audit.Log addition is implemented by *Logger, *PostgresLogger and the test countingAuditLog, and no other in-repo type implements the interface; the telemetry extraction preserves executor.go's traceparent and annotation semantics including the all-zeros rejection, and strings is still used elsewhere in that file; the 2s derivation timeout derives from the request context with defer cancel and leaks no goroutine; IncidentSummary returns counts only, so every alert-returning response is labelled. No MCP tools were added, so the server_test.go count is unaffected.
go build, go vet and go test were not available in this review sandbox, so compile, lint and test status is unverified — please rely on the validate workflow.
The argo producer sends no workflow_name and the dedup path keeps the first summary, so a recurring incident kept deriving the first run's trace. CreateIncident now stores the workflow name parsed from the summary, and the dedup clause carries it forward with each occurrence. Also casts the Update trace parameter to text, documents that a trace without a name clears the stale name (with a test), lists every trace_unavailable reason in the MCP description, and documents that no API path clears the trace and PATCH never sets workflow_name. Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
|
Claude finished @mashkovd's task in 4m 58s —— View job Review complete — approved
0 P1 · 0 P2 · 2 P3 — review, both inline comments bound to Previous findings: all closed
P3 — nice to have (non-blocking)
One correction to the PR bodyThe DB-gated tests do not skip in CI. Verified clean on this commit
|
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
| Evidence []Evidence `json:"evidence,omitempty"` | ||
| // TraceID is the 32-hex W3C trace id reported for the run that handled | ||
| // this incident. WorkflowName is the Argo workflow it came from. Both are | ||
| // stored; empty means not reported. They describe the newest occurrence |
There was a problem hiding this comment.
P3 — "They describe the newest occurrence" is not quite what the dedup rules implement, and the gap is the same staleness f2472b6 just fixed on the other path.
The dedup CASE keeps the pair when the new occurrence carries no trace (store.go:146-152, covered by TestTraceID_Dedup as "no trace keeps the pair"). So with a stored trace the pair is pinned to the newest occurrence that reported a trace, not the newest occurrence:
- occurrence 1 posts
trace_id=T1,workflow_name=run1-> row(T1, run1) - occurrence 2 posts no trace (its leg dropped the traceparent), summary ->
run2-> row stays(T1, run1) GETreturnsT1withtrace_source: "reported",occurrence_count: 2,last_seen_at= now, and notrace_unavailable
That is the first-run answer presented as fresh — exactly what the workflow_name fallback in CreateIncident was added to stop on the derived path. It is harmless today, because the argo producers send neither field (per the workflowURLRe comment), but the whole point of #585 is that producers start sending trace_id, at which point this is the live shape.
Keeping the trace rather than dropping it is the right default, so no behaviour change is needed. Worth narrowing the sentence to what the code does, e.g. "they describe the newest occurrence that reported a trace, or the newest occurrence while none has", so a responder reading trace_source: "reported" on a recurring incident knows it may predate last_seen_at.
| // name from it so a dedup hit, which keeps the first occurrence's | ||
| // summary, still records the newest run and derivation follows it. | ||
| if a.WorkflowName == "" && a.Source == incidentSourceArgo { | ||
| a.WorkflowName = workflowNameFromSummary(a.Summary) |
There was a problem hiding this comment.
P3 — The fallback fixes the trace, and leaves the response naming two different runs.
This closes the stale-derivation finding: the stored workflow_name now tracks the newest occurrence, the dedup clause promotes it while the row is untraced, and TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun proves GET derives run 2 rather than run 1. Good fix, and the test is the right shape — two real audit entries, two POSTs, one GET through the handler.
The leftover is that summary is still not in the dedup DO UPDATE SET list, so after this change a deduped incident returns workflow_name/trace_id for run 2 next to a summary whose Argo UI link is run 1 — the new test asserts exactly that state (id is ...-run1, workflow_name is ...-run2). summary is the field a human actually reads in a Telegram notification, so a responder who clicks it lands on the run that is not the one the trace describes, which is the same wrong-run pivot in the other direction.
Two cheap options: add summary = EXCLUDED.summary to the dedup SET so the whole row describes the newest occurrence (it is already the semantics last_seen_at and occurrence_count imply), or note in the Summary field docs that it is the first occurrence's text while the trace fields are the newest. Either way worth a line, since the divergence is now deliberate rather than incidental.
There was a problem hiding this comment.
No P1/P2 findings (2 P3). Good to merge.
f2472b6 closes the last P2 and all three P3s from the previous round. The deduped-argo case is fixed at the source: CreateIncident now stores the workflow name parsed from the summary for argo incidents, so the dedup clause that promotes a newer name while the row is untraced actually fires, and TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun proves end to end that GET derives run 2 rather than run 1 (run 1 is seeded with a different traceparent, so a regression fails loudly). workflowNameFromSummary is factored out and shared with the derivation path. Update now casts $12::text so the non-destructive CASE no longer depends on the all-unknown-to-text fallback. TestTraceID_Dedup gains the (trace, no name) row, and the rule is stated in the SQL comment. The mctl_get_incident description now enumerates the full reason set including not_derived and terminal not_reported.
Re-verified on this commit: alertColumns/scanAlert stay in lockstep across INSERT (23 columns / $1..$23), Update, Get, GetByPrefix and List, and Summary selects only counts; every alert-returning response is labelled exactly once (create, patch, ack, resolve via labelStoredTrace, list via labelStoredTrace, GET via deriveIncidentTrace), and resolve-by-fingerprint returns a bool; all DO UPDATE SET expressions read the pre-update row, so the two CASEs cannot observe each other; the caller-controlled workflow name is DNS1123-validated and only reaches a parameterized query, gated by canReadAuditEntry; the derivation timeout derives from the request context with defer cancel and the derived trace is never written back; LookupByWorkflow maps pgx.ErrNoRows to (nil, nil), wraps every other failure, and PostgresLogger.Log is synchronous, so the new lookup test has no write/read race; the telemetry extraction preserves executor.go s traceparent and annotation semantics including the all-zeros rejection. No MCP tools were added, so the server_test.go count is unaffected.
P3 (non-blocking): the types.go sentence "they describe the newest occurrence" is not what the keep-the-pair dedup rule implements — with a stored trace the pair is pinned to the newest occurrence that reported one, so once producers start sending trace_id a recurring incident will return the first traced run as trace_source=reported with no staleness marker; and summary is still absent from the dedup SET, so a deduped response now pairs run 2 s workflow_name/trace_id with run 1 s summary link.
One correction to the PR body: the DB-gated tests do NOT skip in CI. validate.yml runs a postgres:16 service and exports TEST_DATABASE_URL (and go test -p 1 for exactly the cross-package DELETE race the body describes), so internal/alerts, internal/api and internal/audit all exercise the new SQL and handler paths there. The "this repo has no Postgres service in CI" comment in handlers_alerts_test.go is stale and predates this PR.
go build, go vet and go test were denied in this review sandbox, so compile, lint and test status is unverified here — please rely on the validate workflow.
Implements accepted proposal
mctl-api/issue-585-feat-incidents-store-and-return-trace-id.Spec: https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-api/proposals/issue-585-feat-incidents-store-and-return-trace-id/
Generated by mctl-agents Tier 2 implementer (
18b0e1d). Hand-added after review:e407e48: test coverage. The generated commit had only the pure derivation and helper tests.869866c: fixes from the Claude review.b87e160: gate and response-label tests.f2472b6: second review round. A deduped argo incident now follows its newest run: CreateIncident stores the workflow name parsed from the summary, because the producer sends none. Also: a$12::textcast, the MCP reason list, and docs.What changed
alerts.trace_idandalerts.workflow_nameare added as additiveADD COLUMN IF NOT EXISTScolumns. Every read path goes through onealertColumnslist and onescanAlertfunction.trace_idandworkflow_namemove together. A new valid trace replaces both, and a hit without a trace keeps both. While no trace is stored, a newerworkflow_namereplaces the older one. This deviates from the proposal's "workflow_name immutable after create", per review: otherwise one run's name could be paired with another run's trace.Updatekeeps the stored trace when the caller carries none.trace_idleniently: an invalid value, including a wrong JSON type, is dropped and never fails the request.GET /incidents/{id}returns the stored trace astrace_source: "reported". For anargo-workflowsincident with no stored trace, it derives the trace at read time from the workflow's audittraceparentand returns it astrace_source: "workflow_audit". The derivation has a 2 s timeout, lets a non-admin read only an audit entry of a team they belong to (canReadAuditEntry; an empty-team platform entry is admin-only, as inGetWorkflowLogs, which is stricter than the proposal's "mirror GetWorkflow"), and never writes back to the row. When no trace can be obtained, the response has exactly onetrace_unavailablereason.GET /incidentsdoes not derive. An argo item without a stored trace saysnot_derived_on_list, and any other source saysnot_reported. Write responses (create, patch, ack, resolve) saynot_derived/not_reportedin the same way, so a missingtrace_idalways carries a reason.trace_sourceis set by the handlers, not by the store.audit.Log.LookupByWorkflowreturns(nil, nil)when the record does not exist and(nil, err)when the read failed. The oldGetByWorkflowcallers are untouched; they are tracked in mctl-api#586.telemetry.ValidTraceIDandtelemetry.TraceIDFromTraceparentare now shared byoperations.workflowTraceIDand the incident path.mctl_get_incidentdescription documentstrace_id,trace_sourceandtrace_unavailable.Tests
alerts.TestTraceID_RoundTripsThroughEveryReadPath(Create RETURNING, Get, GetByPrefix, List, an ack-style Update round-trip, and a trace-replacing Update)alerts.TestTraceID_Dedupapi.TestAlertJSONTraceKeyapi.TestIncidentTrace_InvalidInputIgnoredNotRejected(uppercase, 31 chars, all zeros, a number, an object, a badworkflow_name; PATCH invalid keeps the trace, PATCH valid replaces it)api.TestDeriveIncidentTrace(all reasons), plusapi.TestIncidentTrace_GetDerivesDae24112FromAudit, which runs end to end throughGetIncidentwith the production summary text and checks that nothing is written backtelemetry.TestValidTraceIDAndTraceparentapi.TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup(an argo item getsnot_derived_on_list, a non-argo item getsnot_reported, and the audit lookup count is 0)api.TestCanReadAuditEntry,api.TestIncidentTrace_GetAsNonAdmin(real non-admin users throughGetIncident),api.TestIncidentTrace_WriteResponsesAreLabelled,api.TestDeriveIncidentTrace_SummaryURLInProseapi.TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun(two argo runs under one fingerprint; GET derives run2's trace),alerts.TestTraceID_Dedupcase "a trace without a name clears the stale name"audit.TestPostgresLogger_LookupByWorkflow(found,(nil, nil)for a missing record, and an error for a cancelled context)go test ./...passes both without and withTEST_DATABASE_URL, run on a local Postgres with-p 1. Packages share the one database, so a parallel run makes the unrelatedinternal/surfaceidtests flaky. CI runs them too:validate.ymlhas a postgres:16 service and uses-p 1. (An earlier version of this body said otherwise; corrected after review.)T9: mutation proof
Each mutation was applied to the fixed code, the named test was run, and then the code was restored.
alertColumns:trace_id→''::text AS trace_idTestTraceID_RoundTripsThroughEveryReadPath:store_trace_test.go:56: Create RETURNING: trace_id="" … want "431486061c652efb376a860d9d656f25"trace_id = CASE …→trace_id = EXCLUDED.trace_idTestTraceID_Dedup:store_trace_test.go:154: dedup without trace: trace_id="" … want "008ad8979d34c0509e74d8062b7f92ca"deriveIncidentTracereturnsaudit_unavailablebefore the lookupTestIncidentTrace_GetDerivesDae24112FromAudit:incident_trace_handlers_test.go:208: got trace_id=<nil> trace_source=<nil> unavailable=audit_unavailable; also failsTestDeriveIncidentTrace/summary_urlTestIncidentTrace_ListMarksAbsenceWithoutAuditLookup:incident_trace_handlers_test.go:178: argo item without a stored trace is unmarkedValidTraceIDcheck removed fromvalidIncidentTraceIDTestIncidentTrace_InvalidInputIgnoredNotRejected/create_uppercase:incident_trace_handlers_test.go:98: invalid trace_id "431486061C652EFB376A860D9D656F25" was storedLookupByWorkflowreturns(nil, nil)on any errorTestPostgresLogger_LookupByWorkflow:lookup_postgres_test.go:60: failed read: entry=<nil> err=<nil>, want (nil, error)canReadAuditEntry:team != "" &&→team == "" ||TestCanReadAuditEntry:non-admin, platform entry: canReadAuditEntry = true, want falseTestDeriveIncidentTrace_SummaryURLInProse:summary "Workflow failed: https://…/mctl-agents-implement-dae24112.": looked up "" … (unavailable="workflow_name_unknown")workflow_name = CASE …is removedTestTraceID_Dedup:store_trace_test.go:152: new trace replaces the pair: … workflow_name="wf-first" … want … "wf-second"Update:trace_id=$12(unconditional)TestTraceID_RoundTripsThroughEveryReadPath:store_trace_test.go:109: Get after Update without trace: trace_id=""labelStoredTracegives non-argo items the underived reasonTestIncidentTrace_ListMarksAbsenceWithoutAuditLookup:non-argo item without a trace: … trace_unavailable:not_derived_on_listTestIncidentTrace_GetAsNonAdmin/platform_op:trace_id="431486061c652efb376a860d9d656f25" trace_unavailable="", want "" / "audit_record_forbidden"TestIncidentTrace_WriteResponsesAreLabelled:create response: trace_id=<nil> trace_unavailable=<nil>workflowNameFromSummaryfallback removedTestIncidentTrace_DedupedArgoIncidentFollowsNewestRun:incident_trace_handlers_test.go:396: derived trace_id=008ad8979d34c0509e74d8062b7f92ca workflow_name=<nil>, want the newest run (431486061c652efb376a860d9d656f25, run2)AND EXCLUDED.workflow_name <> ''TestTraceID_Dedup:store_trace_test.go:155: a trace without a name clears the stale name: … workflow_name="wf-second"M1, M3 and M4 were re-run after
869866cand still fail. M4 was redone as_ = iso that it fails the test rather than the build.Rollout check (proposal task 8, after deploy)
mctl_get_incident argo-mctl-agents-implement-dae24112-1791756910must returntrace_id=431486061c652efb376a860d9d656f25withtrace_source=workflow_audit. This check gates closing mctlhq/mctl-agent#97.Closes #585
🤖 Generated with Claude Code
https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7